File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useRouter } from 'next/navigation';
import { useActionState, useState } from 'react';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
import { FoxCard } from '@fox/core/components/fox-card';
import { FoxDescriptionList } from '@fox/core/components/fox-description-list';
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
import { FoxInput } from '@fox/core/components/fox-input';
import { FoxPageHeader } from '@fox/core/components/fox-page-header';
import { FoxSelect } from '@fox/core/components/fox-select';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
import { useFeedback } from '@/app/_hooks/use-feedback';
import type { CommonCode } from '@/lib/domain/common-code';
import {
MERCHANT_DETAIL_VISIBILITY_OPTIONS,
MERCHANT_VOUCHER_USE_OPTIONS,
formatMerchantDetailStatusLabel,
formatOptionalValue,
type MerchantDetail,
} from '@/lib/domain/merchant-detail';
import {
CLOSED_DAYS_MAX_LENGTH,
DESCRIPTION_MAX_LENGTH,
INITIAL_MERCHANT_DETAIL_FORM_STATE,
SORT_ORDER_MAX,
} from '@/lib/domain/merchant-detail-form';
import { MERCHANT_DETAILS_PATH } from '@/lib/domain/merchant-detail-query';
import type { MerchantProduct } from '@/lib/domain/merchant-product';
import { formatPhoneNumber } from '@/lib/domain/phone-number';
import {
deleteMerchantDetailAction,
saveMerchantDetailAction,
updateMerchantDetailVisibilityAction,
} from '../../_actions';
import { MerchantChoiceChips } from '../../_components/merchant-choice-chips';
import { MerchantImageField } from '../../_components/merchant-image-field';
import { MerchantProductTable } from '../../_components/merchant-product-table';
import styles from './merchant-detail-form.module.scss';
interface MerchantDetailFormProps {
detail: MerchantDetail;
products: MerchantProduct[];
categories: CommonCode[];
}
/**
* 가맹몰 상세 등록·수정 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다. 상세는
* 가맹점당 하나라 `frcsNo`만으로 등록인지 수정인지 갈린다(`detail.hasDetail`).
*
* 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은
* 가맹점 관리 화면의 것이고 여기서는 값을 바꾸지 않는다.
*/
export function MerchantDetailForm({ detail, products, categories }: MerchantDetailFormProps) {
const router = useRouter();
const { showAlert, hideAlert, showToast } = useFeedback();
const [isBusy, startAction] = useBlockingAction();
const [state, formAction, isPending] = useActionState(
saveMerchantDetailAction,
INITIAL_MERCHANT_DETAIL_FORM_STATE
);
const isEdit = detail.hasDetail;
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
const [categoryCode, setCategoryCode] = useState(detail.categoryCode ?? '');
const [visibility, setVisibility] = useState<string[]>([
detail.isVisible === false ? 'N' : 'Y',
]);
const [voucherUse, setVoucherUse] = useState<string[]>(['Y']);
function toggleVisibility() {
const next = detail.isVisible === false;
startAction(async () => {
const result = await updateMerchantDetailVisibilityAction(detail.id, next);
showToast(
result.status === 'error'
? { variant: 'danger', message: result.message }
: { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' }
);
});
}
function runDelete() {
hideAlert();
startAction(async () => {
const result = await deleteMerchantDetailAction(detail.id);
if (result.status === 'error') {
showToast({ variant: 'danger', message: result.message });
return;
}
showToast({ variant: 'success', message: '삭제했습니다.' });
router.push(MERCHANT_DETAILS_PATH);
});
}
function confirmDelete() {
showAlert({
variant: 'danger',
title: '가맹몰 상세를 삭제하시겠습니까?',
message: `"${detail.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
actions: (
<FoxButtonGroup size="md">
<FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
<FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
</FoxButtonGroup>
),
});
}
return (
<>
<FoxPageHeader
title={`가맹몰 상세 ${isEdit ? '수정' : '등록'}`}
description={
isEdit
? '등록된 업체 정보를 수정한다. 저장 즉시 앱에 반영된다'
: '지역상생몰에 노출할 업체를 등록한다'
}
breadcrumb={[
{ label: '홈', href: '/' },
{ label: '관리자관리' },
{ label: '가맹몰 상세 관리', href: MERCHANT_DETAILS_PATH },
{ label: isEdit ? '수정' : '등록' },
]}
/>
<form
id="merchant-detail-form"
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.form}
>
<input type="hidden" name="frcsNo" value={detail.id} />
<input type="hidden" name="mode" value={isEdit ? 'edit' : 'create'} />
<FoxCard size="lg" title="기본 정보" className={styles.section}>
<div className={styles.grid}>
<div className={styles.fieldWide}>
<FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
</div>
<div className={styles.field}>
<FoxSelect
size="md"
label="카테고리"
requirement="required"
placeholder="선택"
options={categories.map((code) => ({ value: code.code, label: code.label }))}
value={categoryCode}
onValueChange={setCategoryCode}
error={Boolean(errors.categoryCode)}
/>
<input type="hidden" name="categoryCode" value={categoryCode} />
{errors.categoryCode && <FoxHelperText type="danger" message={errors.categoryCode} />}
</div>
<div className={styles.field}>
<FoxInput
size="md"
label="업체 연락처"
requirement="required"
value={formatOptionalValue(formatPhoneNumber(detail.phoneNumber))}
readOnly
state="view"
/>
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldNarrow}>
<FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail.postalCode)} readOnly state="view" />
</div>
<div className={styles.fieldWide}>
<FoxInput size="md" label="주소" value={formatOptionalValue(detail.address)} readOnly state="view" />
</div>
<div className={styles.field}>
<FoxInput size="md" label="상세주소" value={formatOptionalValue(detail.addressDetail)} readOnly state="view" />
</div>
</div>
<div className={styles.grid}>
<div className={styles.fieldWide}>
<FoxInput
size="md"
name="description"
label="한줄 소개"
placeholder="앱 목록·상세에 노출되는 한 줄 설명"
defaultValue={detail.description ?? ''}
maxLength={DESCRIPTION_MAX_LENGTH}
invalid={Boolean(errors.description)}
message={errors.description}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="openTime"
type="time"
label="영업 시작"
defaultValue={detail.openTime ?? ''}
invalid={Boolean(errors.openTime)}
message={errors.openTime}
/>
</div>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="closeTime"
type="time"
label="영업 종료"
defaultValue={detail.closeTime ?? ''}
invalid={Boolean(errors.closeTime)}
message={errors.closeTime}
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="closedDays"
label="휴무일"
placeholder="예) 매주 월요일"
defaultValue={detail.closedDays ?? ''}
maxLength={CLOSED_DAYS_MAX_LENGTH}
invalid={Boolean(errors.closedDays)}
message={errors.closedDays}
/>
</div>
</div>
<div className={styles.grid}>
<MerchantChoiceChips
label="노출 상태"
name="isVisible"
required
options={MERCHANT_DETAIL_VISIBILITY_OPTIONS}
values={visibility}
onChange={setVisibility}
/>
<MerchantChoiceChips
label="바우처 사용"
name="isVoucherUsable"
required
options={MERCHANT_VOUCHER_USE_OPTIONS}
values={voucherUse}
onChange={setVoucherUse}
/>
<div className={styles.fieldNarrow}>
<FoxInput
size="md"
name="sortOrder"
type="number"
inputMode="numeric"
min={0}
max={SORT_ORDER_MAX}
label="노출 순서"
placeholder="숫자 (낮을수록 상단)"
defaultValue={detail.sortOrder === null ? '' : String(detail.sortOrder)}
invalid={Boolean(errors.sortOrder)}
message={errors.sortOrder}
/>
</div>
</div>
</FoxCard>
<FoxCard
size="lg"
title="이미지 등록"
description="대표 1장 필수 · 추가 최대 11장 (앱 슬라이더 최대 12장)"
className={styles.section}
>
<MerchantImageField
defaultThumbnailUrl={detail.thumbnailUrl}
retainedThumbnailId={detail.thumbnailFileId}
defaultImageUrls={detail.imageUrls}
retainedImageFileId={detail.imageFileId}
thumbnailError={errors.thumbnail}
imagesError={errors.images}
/>
</FoxCard>
<FoxCard
size="lg"
title="메뉴 정보"
description="앱 상세 화면의 메뉴 목록에 노출된다"
className={styles.section}
>
<MerchantProductTable frcsNo={detail.id} products={products} canAdd={isEdit} />
</FoxCard>
{isEdit && (
<FoxCard size="lg" title="등록 정보" className={styles.section}>
<FoxDescriptionList
layout="inline"
items={[
{ key: 'registeredAt', term: '등록일시', description: formatOptionalValue(detail.registeredAt) },
{ key: 'registeredBy', term: '등록자', description: formatOptionalValue(detail.registeredBy) },
{
key: 'modified',
term: '최종 수정',
description: [detail.modifiedAt, detail.modifiedBy]
.filter((value): value is string => Boolean(value))
.join(' / ') || '-',
},
{ key: 'status', term: '현재 상태', description: formatMerchantDetailStatusLabel(detail) },
]}
/>
</FoxCard>
)}
{state.status === 'error' && state.message && (
<FoxHelperText type="danger" message={state.message} />
)}
<FoxButtonPanel>
<FoxButtonGroup size="lg">
{isEdit && (
<>
<FoxButton type="default" size="lg" label="삭제" disabled={isBusy} onAction={confirmDelete} />
<FoxButton
type="default"
size="lg"
label={detail.isVisible === false ? '노출' : '비활성'}
disabled={isBusy}
onAction={toggleVisibility}
/>
</>
)}
<FoxButton
type="default"
size="lg"
label="취소"
onAction={() => router.push(MERCHANT_DETAILS_PATH)}
/>
<FoxButton
type="primary"
size="lg"
htmlType="submit"
label={isEdit ? '저장' : '등록'}
loading={isPending}
/>
</FoxButtonGroup>
</FoxButtonPanel>
</form>
</>
);
}